
到目前為止,我們有了 Logging、Error Handling、CORS 三個 middleware。使用起來是這樣
app.use(loggingMiddleware(logger))
app.use(errorHandlingMiddleware(development = true))
app.use(corsMiddleware(CorsConfig(allowedOrigins = listOf("*"))))
雖然可以跑,但程式碼看起來不像框架,使用者要記每個 middleware 的工廠函式名稱、要知道參數怎麼傳、安裝順序要自己管,我們需要一個統一的「安裝」機制,讓使用體驗變成這樣
app.install(Logging) {
logger = ConsoleLogger()
}
app.install(Cors) {
allowedOrigins = listOf("*")
}
這一層統一由 Plugin 系統處理
先把兩個東西分清楚
install() 安裝功能,plugin 內部決定要註冊哪些 middleware、做哪些初始化Plugin 通常只做一件事,把一組 middleware 註冊進 pipeline,再加上設定和生命週期管理,你可以把它想成 middleware 的包裝
順便對照其他擴充模式,繼承式設計會把行為塞進父子類別關係,功能越多,階層越容易變難懂,Mixin / trait 比較彈性,但也可能遇到名稱衝突和除錯路徑不直覺的問題,install() 走的是 composition over inheritance,application 是 host,plugin 是 guest,guest 透過約定好的安裝點 (這裡就是 middleware queue) 把自己掛上來,安裝順序就是執行順序,想移除某個功能也只要拿掉一行 install。Ktor、Express、Koa 都是類似思路,差別在 install 點的形狀
最小的 plugin 介面需要兩個東西,一個設定型別,一個安裝方法
interface RelixPlugin<TConfig : Any> {
fun createDefaultConfig(): TConfig
fun install(application: RelixApplication, config: TConfig)
}
createDefaultConfig() 回傳一個有預設值的 config 物件,install() 拿到 application 和設定好的 config,把 middleware 註冊進去
為什麼 config 型別用泛型 ? 因為每個 plugin 的設定不同,Logging 需要 logger 實例,CORS 需要 origin 白名單,Error Handling 需要 dev/prod 開關,泛型讓 compiler 幫你檢查型別
install() 的機制要單獨用假 plugin 來測,不要等三個真 plugin 寫完才一起測,真的 plugin 一進來,測試表面上驗的是 install(),實際上同時依賴了 loggingMiddleware 跟 logger 的實作,哪天 logging 壞掉,機制測試也會跟著出事,看測試名稱分不出來是哪一層出的事
另一個問題是覆蓋率,install() 要做的事不只一件,建預設 config、套用使用者的 lambda、註冊 middleware、擋掉重複安裝,只用 Logging 驗得到其中兩件,後面常見陷阱那節會說安裝順序等於 middleware 順序,那句話得有測試覆蓋才算數
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
class PluginTest {
class ProbeConfig(var tag: String = "probe")
// 假 plugin,只把足跡記進 trace,不依賴任何真的 middleware
open class Probe(private val trace: MutableList<String>) : RelixPlugin<ProbeConfig> {
override fun createDefaultConfig() = ProbeConfig()
override fun install(application: RelixApplication, config: ProbeConfig) {
application.use { next ->
trace += "enter ${config.tag}"
next()
}
}
}
class OuterProbe(trace: MutableList<String>) : Probe(trace)
class InnerProbe(trace: MutableList<String>) : Probe(trace)
private fun appWithHello() = RelixApplication().apply {
routing {
get("/hello") { ok("Hello!") }
}
}
@Test
fun `default config is used when no lambda is given`() {
val trace = mutableListOf<String>()
val app = appWithHello()
app.install(OuterProbe(trace))
RelixTestKit(app).handleRequest("GET", "/hello")
assertEquals(listOf("enter probe"), trace)
}
@Test
fun `config lambda is applied before the plugin installs`() {
val trace = mutableListOf<String>()
val app = appWithHello()
app.install(OuterProbe(trace)) { tag = "custom" }
RelixTestKit(app).handleRequest("GET", "/hello")
assertEquals(listOf("enter custom"), trace)
}
@Test
fun `install order is middleware order`() {
val trace = mutableListOf<String>()
val app = appWithHello()
app.install(OuterProbe(trace)) { tag = "outer" }
app.install(InnerProbe(trace)) { tag = "inner" }
RelixTestKit(app).handleRequest("GET", "/hello")
assertEquals(listOf("enter outer", "enter inner"), trace)
}
@Test
fun `duplicate installation is detected by type not by instance`() {
val trace = mutableListOf<String>()
val app = appWithHello()
app.install(OuterProbe(trace))
val error = assertFailsWith<IllegalStateException> {
app.install(OuterProbe(trace))
}
assertTrue(error.message!!.contains("OuterProbe"))
assertTrue(error.message!!.contains("already installed"))
}
}
機制組刻意用 Probe 這個假 plugin,它註冊的 middleware 只把足跡記進 trace,不會用前面幾篇寫的那三個 middleware,如果失敗,問題一定出在 install() 本身
OuterProbe 跟 InnerProbe 是同一個 Probe 的兩個子類別,重複偵測看的是 plugin 的型別,想同時裝兩個假 plugin 就得有兩個型別,這個安排剛好也對照到上面那個重複偵測的測試
重複偵測那個測試除了驗 exception 的型別,也接住 exception 驗訊息裡有沒有那個 plugin 的名字,只驗型別的話,訊息改成什麼樣子測試都不會出聲,使用者裝重複了卻不知道是哪一個 plugin 撞到,這裡用 contains 而不是整句比對,是因為要定住的是「訊息要講出是哪個 plugin」,不是文案本身,只驗證重點文字就好
install order is middleware order 是把常見陷阱那句斷言變成測試,先 install 的 Probe 在 pipeline 外層,request 進來時 trace 的順序就是 outer 先、inner 後,順序反了測試立刻失敗
上一節那四個測試已經把 install 要長什麼樣講完了,接著把它寫出來,讓使用者可以安裝 plugin 並用 lambda 調整設定
class RelixApplication {
private val installedPlugins = mutableSetOf<kotlin.reflect.KClass<*>>()
fun <TConfig : Any> install(
plugin: RelixPlugin<TConfig>,
configure: TConfig.() -> Unit = {},
) {
val pluginName = plugin::class.simpleName ?: "Unknown"
check(installedPlugins.add(plugin::class)) {
"Plugin '$pluginName' is already installed"
}
val config = plugin.createDefaultConfig()
config.configure()
plugin.install(this, config)
}
// ...
}
install() 做四件事
install() 讓它註冊 middlewareplugin::class.simpleName ?: "Unknown" 那個 ?: 是給匿名 plugin 的保險。用 object Logging : RelixPlugin<...> 或一般 class 定義的 plugin 都有名字,但使用者也可以直接塞一個匿名的 object : RelixPlugin<ProbeConfig> { ... } 進來,這時候 simpleName 是 null,訊息就只能寫成 Plugin 'Unknown' is already installed
這條分支上面的測試沒有覆蓋,四個測試用的 plugin 都有名字,"Unknown" 基本上是一次也走不到,留著是因為少了它 simpleName 的 null 會直接讓訊息變成 Plugin 'null' is already installed,看起來更像 bug,要讓匿名 plugin 在錯誤訊息裡也認得出來,就得讓 plugin 自己帶一個 key,而不是靠 class 名字,那是後面「重複安裝偵測擋的是什麼」會再提到的同一件事
configure: TConfig.() -> Unit = {} 是 lambda with receiver,所以使用者在 block 裡可以直接寫 allowedOrigins = listOf("*"),不用寫 config.allowedOrigins = ...
把第 15 篇的 loggingMiddleware 包成 plugin
class LoggingConfig {
var logger: RelixLogger = ConsoleLogger()
}
object Logging : RelixPlugin<LoggingConfig> {
override fun createDefaultConfig() = LoggingConfig()
override fun install(application: RelixApplication, config: LoggingConfig) {
application.use(loggingMiddleware(config.logger))
}
}
用 object 是因為 plugin 本身不持有狀態,它只是一個安裝邏輯的入口,LoggingConfig 是可變的 class (不是 data class),因為使用者要在 lambda 裡用 = 賦值
ConsoleLogger() 這個預設值現在寫了兩次,一次在 loggingMiddleware() 的參數預設值,一次在 LoggingConfig 裡。有了 plugin 系統之後,預設值一律以 Config 為準,工廠函式那邊的預設參數留著只是為了讓底層 API 單獨用得動,之後要改預設值,只改 Config 一個地方
三個 middleware 都是同樣情況,loggingMiddleware 的 logger、errorHandlingMiddleware 的 development 與 logger、corsMiddleware 的 CorsConfig(),工廠函式那邊的預設值,使用者已經碰不到了
使用者的安裝方式
app.install(Logging) {
logger = ConsoleLogger() // 或自訂的 logger
}
同樣的模式套用到其他兩個 middleware
class ErrorHandlingConfig {
var development: Boolean = false
var logger: RelixLogger = ConsoleLogger()
}
object ErrorHandling : RelixPlugin<ErrorHandlingConfig> {
override fun createDefaultConfig() = ErrorHandlingConfig()
override fun install(application: RelixApplication, config: ErrorHandlingConfig) {
application.use(errorHandlingMiddleware(config.development, config.logger))
}
}
logger 這個欄位不能少。第 16 篇後半才讓 error handling middleware 自己拿到 logger,沒被接住的 exception 才記得下 stack trace,config 這裡要一併帶上,不然換成 plugin 就等於把那個能力弄丟了,使用者永遠只能用預設的 ConsoleLogger()
第 16 篇也提過 logging 跟 error handling 最好共用同一個 logger,plugin 版本這樣寫
val logger = ConsoleLogger()
app.install(Logging) { this.logger = logger }
app.install(ErrorHandling) {
development = true
this.logger = logger
}
兩個 config 的欄位都叫 logger,跟外面那個 val logger 撞名,lambda 裡的 logger 會先解析到區域變數而不是 config 的欄位,所以要寫 this.logger。少了 this. 這段會直接編譯失敗,錯誤訊息是 'val' cannot be reassigned。這算是撞名裡面比較好處理的一種,外面那個如果宣告成 var,編譯器就不會出聲,只會安靜地把值指回它自己,那種才難抓
object Cors : RelixPlugin<CorsConfig> {
override fun createDefaultConfig() = CorsConfig()
override fun install(application: RelixApplication, config: CorsConfig) {
application.use(corsMiddleware(config))
}
}
CORS 比較特別,它的 config 就是第 17 篇定義的 CorsConfig data class,直接拿來用就好,不過要注意 CorsConfig 原本的欄位是 val,如果要讓使用者在 lambda 裡用 = 賦值,要改成 var
// 修改前(第 17 篇)
data class CorsConfig(
val allowedOrigins: List<String> = listOf("*"),
...
)
// 修改後(Plugin 版)
data class CorsConfig(
var allowedOrigins: List<String> = listOf("*"),
var allowedMethods: List<String> = listOf("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"),
var allowedHeaders: List<String> = listOf("Content-Type", "Authorization"),
var maxAge: Int = 86400,
)
val 改 var 的原因是 lambda with receiver 的語法 this.allowedOrigins = listOf(...) 需要 setter
三個 plugin 都包好了,回頭驗一件事,包裝有沒有包對
先講清楚這組跟前面那組機制測試的分工,機制組驗的是 install() 這個安裝流程本身,用假 plugin 就夠了,這組再往裡面一層,驗的是每個真 plugin 有沒有把對的 middleware 註冊進去
為什麼這裡不是用 TDD 的方式 ? 三個 middleware 的行為在第 15 到 17 篇已經有測試撐著,plugin 只是換一個安裝方式,所以這裡是實作先寫、測試後補,它的角色是重構的安全網,確認換成 install() 之後行為沒有跑掉
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class PluginIntegrationTest {
private fun appWithHello() = RelixApplication().apply {
routing {
get("/hello") { ok("Hello!") }
}
}
@Test
fun `Logging plugin registers the logging middleware`() {
val logger = FakeLogger()
val app = appWithHello()
app.install(Logging) {
this.logger = logger
}
RelixTestKit(app).handleRequest("GET", "/hello")
assertEquals(1, logger.messages.size)
assertTrue(logger.messages.first().contains("GET"))
}
@Test
fun `ErrorHandling plugin uses the configured logger`() {
val logger = FakeLogger()
val app = RelixApplication()
app.install(ErrorHandling) {
development = true
this.logger = logger
}
app.routing {
get("/boom") { throw RuntimeException("something broke") }
}
val response = RelixTestKit(app).handleRequest("GET", "/boom")
assertEquals(500, response.statusCode)
assertEquals(1, logger.errors.size)
}
@Test
fun `Logging, ErrorHandling and Cors work together`() {
val app = appWithHello()
app.install(Logging)
app.install(ErrorHandling) {
development = true
}
app.install(Cors) {
allowedOrigins = listOf("http://localhost:5173")
}
val response = RelixTestKit(app).handleRequest("GET", "/hello")
assertEquals(200, response.statusCode)
}
}
ErrorHandling plugin uses the configured logger 這個測試,就是前面 ErrorHandlingConfig 那個 logger 欄位的存在理由,config 沒有 logger 欄位的話,middleware 只會拿到自己內建的 ConsoleLogger(),logger.errors 一直是空的,這個測試就會失敗
這三個測試各自驗證一件事,Logging 要看得到 log、ErrorHandling 要把 exception 轉成 500 並記下來、三個裝在一起不能互相打架
有些 plugin 需要在框架啟動時初始化資源 (建立連線池),或在關閉時釋放資源,基本上使用兩個小的 hook 就夠用,不過在動手寫之前,先把它們該有的行為想清楚
前面兩組測試驗的是 install() 的機制跟三個 plugin 的包裝,hook 是另一組行為,而且它有一個容易寫錯的地方,註冊跟執行是分開的兩件事
等一下 onStarted() 的實作只會把 lambda 放進 list,不會馬上執行它,如果哪天有人手滑寫成直接呼叫 block(),連線池就會在安裝當下建立,而不是 server 起來之後,這種錯誤不會有任何編譯或執行期警告
所以測試要分開驗註冊跟執行,跟 install() 差不多,onStarted、onStopping 負責註冊,fireStarted、fireStopping 負責觸發,這四個方法都還沒寫,下面這段一樣是編不過的
import kotlin.test.Test
import kotlin.test.assertEquals
class LifecycleHookTest {
class DatabaseConfig(var url: String = "mem://")
// 用一個會留下足跡的假 plugin 代替真的連線池
class RecordingDatabasePlugin(private val trace: MutableList<String>) :
RelixPlugin<DatabaseConfig> {
override fun createDefaultConfig() = DatabaseConfig()
override fun install(application: RelixApplication, config: DatabaseConfig) {
application.onStarted { trace += "open pool ${config.url}" }
application.onStopping { trace += "close pool" }
}
}
@Test
fun `hooks do not run at install time`() {
val trace = mutableListOf<String>()
val app = RelixApplication()
app.install(RecordingDatabasePlugin(trace))
assertEquals(emptyList(), trace)
}
@Test
fun `started hooks run in registration order`() {
val trace = mutableListOf<String>()
val app = RelixApplication()
app.onStarted { trace += "first" }
app.onStarted { trace += "second" }
app.fireStarted()
assertEquals(listOf("first", "second"), trace)
}
@Test
fun `fireStarted runs only started hooks`() {
val trace = mutableListOf<String>()
val app = RelixApplication()
app.onStarted { trace += "started" }
app.onStopping { trace += "stopping" }
app.fireStarted()
assertEquals(listOf("started"), trace)
}
@Test
fun `plugin hooks fire with the configured value`() {
val trace = mutableListOf<String>()
val app = RelixApplication()
app.install(RecordingDatabasePlugin(trace)) { url = "postgres://localhost" }
app.fireStarted()
app.fireStopping()
assertEquals(
listOf("open pool postgres://localhost", "close pool"),
trace,
)
}
}
第一個測試就是在防上面說的那個手滑,安裝完 trace 必須是空的
最後一個測試順便驗了一件容易被忽略的事,plugin 的 hook 抓到的 config 是使用者調整之後的值,install() 的順序是先建預設 config,再跑使用者的 lambda,最後才呼叫 plugin.install(),所以 hook 裡的 config.url 拿到的是 postgres://localhost,如果哪天有人把這三步的順序調換,這個測試會直接抓到
hook 只驗到「有沒有被呼叫」和「呼叫順序」這一層就夠了,等一下會看到 adapter 在 start() 跟 stop() 裡呼叫 fireStarted() / fireStopping(),但 adapter 有沒有挑對時機,是 adapter 自己的責任,得開真的 server 才驗得到,第 25 篇把生命週期搬進 RelixApplication 時,會一起處理這兩個觸發點該擺哪
接著把上面那四個測試要的方法補上
class RelixApplication {
private val onStartedHooks = mutableListOf<() -> Unit>()
private val onStoppingHooks = mutableListOf<() -> Unit>()
fun onStarted(block: () -> Unit) {
onStartedHooks += block
}
fun onStopping(block: () -> Unit) {
onStoppingHooks += block
}
fun fireStarted() {
onStartedHooks.forEach { it() }
}
fun fireStopping() {
onStoppingHooks.forEach { it() }
}
// ...
}
JDK adapter 在 start() 和 stop() 時呼叫
class JdkHttpServerAdapter(private val application: RelixApplication) {
fun start(port: Int = 8080) {
// ...
server.start()
this.port = server.address.port
application.fireStarted()
}
fun stop() {
application.fireStopping()
server.stop(0)
}
}
Plugin 在 install() 裡可以註冊 hook
object DatabasePlugin : RelixPlugin<DatabaseConfig> {
override fun createDefaultConfig() = DatabaseConfig()
override fun install(application: RelixApplication, config: DatabaseConfig) {
application.onStarted {
// 建立連線池
}
application.onStopping {
// 關閉連線池
}
}
}
在這裡先做到「能註冊、能呼叫」就好,第 25 篇做 Configuration 和 graceful shutdown 時再加更多細節
重構前,直接操作 middleware
val logger = ConsoleLogger()
val app = RelixApplication()
app.use(loggingMiddleware(logger))
app.use(errorHandlingMiddleware(development = true))
app.use(corsMiddleware(CorsConfig(
allowedOrigins = listOf("http://localhost:5173"),
)))
重構後,用 Plugin API
val app = RelixApplication()
app.install(Logging) {
logger = ConsoleLogger()
}
app.install(ErrorHandling) {
development = true
}
app.install(Cors) {
allowedOrigins = listOf("http://localhost:5173")
}
差別不只是語法,Plugin 版本多了重複安裝偵測、統一的設定方式、生命週期管理,使用者不用記工廠函式名稱,IDE 的自動完成會列出 config 的所有屬性
三個 plugin 加上一個 lifecycle hook,裝進 main 跑起來
fun main() {
val logger = ConsoleLogger()
val app = RelixApplication()
app.install(Logging) {
this.logger = logger
}
app.install(ErrorHandling) {
development = true
this.logger = logger
}
app.install(Cors) {
allowedOrigins = listOf("http://localhost:5173")
}
app.onStarted {
println("[App] Relix started on 8080")
}
app.routing {
get("/api/data") { ok("data") }
get("/boom") { throw RuntimeException("something broke") }
}
JdkHttpServerAdapter(app).start(8080)
}
server 一起來就會先看到 hook 印的那行
[App] Relix started on 8080
這行剛好驗到前面 Lifecycle Hooks 那節沒辦法測的部分,單元測試只驗得到 fireStarted() 有沒有跑到 hook,adapter 到底有沒有在 server.start() 之後呼叫它,要開真的 server 才看得出來,看到這行就代表順序是對的
接著打兩個 request
curl -i -H "Origin: http://localhost:5173" localhost:8080/api/data
curl localhost:8080/boom
第一個是正常的跨域請求
HTTP/1.1 200 OK
Content-type: text/plain; charset=utf-8
Access-control-allow-origin: http://localhost:5173
Vary: Origin
Content-length: 4
data
header 名稱的大小寫跟第 17 篇寫的不一樣,Access-control-allow-origin 不是打錯,JDK HttpServer 的 Headers 會把 key 正規化成首字母大寫、其餘小寫再送出去,HTTP header 名稱本來就不分大小寫,瀏覽器跟 curl 都照收,這只是輸出長相的差別,不影響 CORS 判斷
Vary: Origin 是第 17 篇白名單模式留下來的,這個回應長什麼樣取決於 request 的 Origin,共享快取才不會把它餵給別的 origin
第二個 handler 直接 throw,被 ErrorHandling 轉成 500,body 是兩行純文字
Internal Server Error
RuntimeException
第二行的 RuntimeException 是開發模式才有的,把 install(ErrorHandling) { development = true } 那行改成 false,就只剩第一行,第 16 篇講過,原始 message 跟 stack trace 不管哪個模式都不會進 response
server 那邊除了兩筆 access log,中間還夾著 error handling 自己記下來的那筆
[Relix] GET /api/data -> 200 (1ms)
[Relix ERROR] Unhandled exception
java.lang.RuntimeException: something broke
at MainKt$main$5$2.invokeSuspend(main.kt:33)
at CorsMiddlewareKt$corsMiddleware$1.invokeSuspend(corsMiddleware.kt:12)
at PipelineKt$buildPipeline$1.invokeSuspend(Pipeline.kt:24)
at ErrorHandlingMiddlewareKt$errorHandlingMiddleware$1.invokeSuspend(ErrorHandlingMiddleware.kt:12)
at PipelineKt$buildPipeline$1.invokeSuspend(Pipeline.kt:24)
at LoggingMiddlewareKt$loggingMiddleware$1.invokeSuspend(LoggingMiddleware.kt:14)
at PipelineKt$buildPipeline$1.invokeSuspend(Pipeline.kt:24)
at RelixApplication.handle(RelixApplication.kt:72)
at JdkHttpServerAdapter$start$1$response$1.invokeSuspend(JdkHttpServerAdapter.kt:27)
...
[Relix] GET /boom -> 500 (1ms)
這段 stack trace 把三層 middleware 的巢狀結構整個攤開,由下往上讀是 RelixApplication.handle → loggingMiddleware → errorHandlingMiddleware → corsMiddleware → handler (main.kt:33),跟 install 的呼叫順序完全一致,Logging 先裝所以在最外層,Cors 最後裝所以貼著 handler,install order is middleware order 那個測試驗的是同一件事,差別在這份是實際跑出來的
這跟第 16、17 篇手動 app.use() 的結果一模一樣,request 走過的還是同樣三層,install 換掉的只有寫法
有一件事沒有因為換成 plugin 而改變,安裝順序還是由你呼叫 install 的先後決定,Logging 寫在最前面,它就在最外層,plugin 系統沒有幫你排順序,也沒辦法幫你排,哪個該在外面是語意問題,只有你自己知道
為什麼用 object 而不是 class 來定義 plugin ?
object Logging : RelixPlugin<LoggingConfig> 是 Kotlin 的 singleton,Plugin 本身不持有狀態,狀態在 config 裡,用 object 讓 install(Logging) 的語法最簡潔,不用寫 install(Logging()),Ktor 內建 plugin 也是用 install(PluginName) 這種穩定的 plugin 值來安裝,自訂 plugin 則通常用 createApplicationPlugin(...) 建出 plugin 值
config 為什麼用 class 而不是 data class ?
基本上都可以,差別在 data class 內建 copy()、equals()、toString(),如果你的 config 單純是設定值,data class 比較方便,但 data class 的欄位如果用 var,copy() 的行為可能讓人困惑 (copy 出來的是 shallow copy)
安裝順序等於 middleware 順序嗎 ?
是的,install() 內部呼叫 use(),所以越早 install 的 plugin,middleware 就在 pipeline 越外層,install order is middleware order 那個測試就是在定住這件事,這代表使用者要注意安裝順序,如果你想讓框架自動排序 (像 Ktor 的 phase-based pipeline),那就需要更複雜的機制,超出本系列的範圍
重複安裝偵測擋的是什麼 ?
installedPlugins.add(plugin::class) 存進去的是 KClass,所以它擋的是「同一個 plugin 型別」,不是「同一個 plugin 實例」
用 object 定義的 plugin 感覺不出這個差別,反正整個程式就只有一個實例,不過用 class 定義的就要注意,像 Lifecycle Hooks 那節的 RecordingDatabasePlugin,兩個帶不同參數的實例也算重複,第二個裝不上去,如果真的需要同一種 plugin 裝多份 (例如兩組設定不同的 CORS 規則),識別方式就得換成自訂的 key 而不是 KClass
Plugin 系統把 middleware 的安裝方式整理成統一的 API,RelixPlugin<TConfig> 介面定義了 createDefaultConfig() 和 install() 兩個方法,RelixApplication.install() 負責建立 config、套用使用者設定、呼叫 plugin 安裝、防止重複安裝
Logging、Error Handling、CORS 三個 middleware 重構成 Plugin 之後,使用體驗從「記工廠函式」變成「install + config lambda」,後續的 Content Negotiation 也會用同一套機制擴展
下一篇開始處理 payload,從最基礎的 request body 讀取與解析開始,支援 text/plain、application/json、form-urlencoded,並加入 body size limit 和 cache
同步刊登於 Blog
圖片來源:AI 產生